const { existsSync, readFileSync } = require('fs')
const { globSync } = require('glob')
const fsPath = require('path')
const { resolve } = require('path')

const container = require('./container')
const Config = require('./config')
const event = require('./event')
const runHook = require('./hooks')
const output = require('./output')
const { emptyFolder } = require('./utils')

/**
 * CodeceptJS runner
 */
class Codecept {
  /**
   * Create CodeceptJS runner.
   * Config and options should be passed
   *
   * @param {*} config
   * @param {*} opts
   */
  constructor(config, opts) {
    this.config = Config.create(config)
    this.opts = opts
    this.testFiles = new Array(0)
    this.requireModules(config.require)
  }

  /**
   * Require modules before codeceptjs running
   *
   * @param {string[]} requiringModules
   */
  requireModules(requiringModules) {
    if (requiringModules) {
      requiringModules.forEach(requiredModule => {
        const isLocalFile = existsSync(requiredModule) || existsSync(`${requiredModule}.js`)
        if (isLocalFile) {
          requiredModule = resolve(requiredModule)
        }
        require(requiredModule)
      })
    }
  }

  /**
   * Initialize CodeceptJS at specific directory.
   * If async initialization is required, pass callback as second parameter.
   *
   * @param {string} dir
   */
  init(dir) {
    this.initGlobals(dir)
    // initializing listeners
    container.create(this.config, this.opts)
    this.runHooks()
  }

  /**
   * Creates global variables
   *
   * @param {string} dir
   */
  initGlobals(dir) {
    global.codecept_dir = dir
    global.output_dir = fsPath.resolve(dir, this.config.output)

    if (this.config.emptyOutputFolder) emptyFolder(global.output_dir)

    if (!this.config.noGlobals) {
      global.Helper = global.codecept_helper = require('@codeceptjs/helper')
      global.actor = global.codecept_actor = require('./actor')
      global.pause = require('./pause')
      global.within = require('./within')
      global.session = require('./session')
      global.DataTable = require('./data/table')
      global.locate = locator => require('./locator').build(locator)
      global.inject = container.support
      global.share = container.share
      global.secret = require('./secret').secret
      global.codecept_debug = output.debug
      global.codeceptjs = require('./index') // load all objects

      // BDD
      const stepDefinitions = require('./mocha/bdd')
      global.Given = stepDefinitions.Given
      global.When = stepDefinitions.When
      global.Then = stepDefinitions.Then
      global.DefineParameterType = stepDefinitions.defineParameterType

      // debug mode
      global.debugMode = false

      // mask sensitive data
      global.maskSensitiveData = this.config.maskSensitiveData || false
    }
  }

  /**
   * Executes hooks.
   */
  runHooks() {
    // default hooks
    runHook(require('./listener/store'))
    runHook(require('./listener/steps'))
    runHook(require('./listener/config'))
    runHook(require('./listener/result'))
    runHook(require('./listener/helpers'))
    runHook(require('./listener/globalTimeout'))
    runHook(require('./listener/globalRetry'))
    runHook(require('./listener/retryEnhancer'))
    runHook(require('./listener/exit'))
    runHook(require('./listener/emptyRun'))

    // custom hooks (previous iteration of plugins)
    this.config.hooks.forEach(hook => runHook(hook))
  }

  /**
   * Executes bootstrap.
   *
   * @returns {Promise<void>}
   */
  async bootstrap() {
    return runHook(this.config.bootstrap, 'bootstrap')
  }

  /**
   * Executes teardown.
   *
   * @returns {Promise<void>}
   */
  async teardown() {
    return runHook(this.config.teardown, 'teardown')
  }

  /**
   * Loads tests by pattern or by config.tests
   *
   * @param {string} [pattern]
   */
  loadTests(pattern) {
    const options = {
      cwd: global.codecept_dir,
    }

    let patterns = [pattern]
    if (!pattern) {
      patterns = []

      // If the user wants to test a specific set of test files as an array or string.
      if (this.config.tests && !this.opts.features) {
        if (Array.isArray(this.config.tests)) {
          patterns.push(...this.config.tests)
        } else {
          patterns.push(this.config.tests)
        }
      }

      if (this.config.gherkin.features && !this.opts.tests) {
        if (Array.isArray(this.config.gherkin.features)) {
          this.config.gherkin.features.forEach(feature => {
            patterns.push(feature)
          })
        } else {
          patterns.push(this.config.gherkin.features)
        }
      }
    }

    for (pattern of patterns) {
      if (pattern) {
        globSync(pattern, options).forEach(file => {
          if (file.includes('node_modules')) return
          if (!fsPath.isAbsolute(file)) {
            file = fsPath.join(global.codecept_dir, file)
          }
          if (!this.testFiles.includes(fsPath.resolve(file))) {
            this.testFiles.push(fsPath.resolve(file))
          }
        })
      }
    }

    if (this.opts.shuffle) {
      this.testFiles = this.shuffle(this.testFiles)
    }

    if (this.opts.shard) {
      this.testFiles = this._applySharding(this.testFiles, this.opts.shard)
    }
  }

  /**
   * Fisher–Yates shuffle (non-mutating)
   */
  shuffle(array) {
    const arr = [...array] // clone to avoid mutating input
    for (let i = arr.length - 1; i > 0; i--) {
      const j = Math.floor(Math.random() * (i + 1))
      ;[arr[i], arr[j]] = [arr[j], arr[i]] // swap
    }
    return arr
  }

  /**
   * Apply sharding to test files based on shard configuration
   *
   * @param {Array<string>} testFiles - Array of test file paths
   * @param {string} shardConfig - Shard configuration in format "index/total" (e.g., "1/4")
   * @returns {Array<string>} - Filtered array of test files for this shard
   */
  _applySharding(testFiles, shardConfig) {
    const shardMatch = shardConfig.match(/^(\d+)\/(\d+)$/)
    if (!shardMatch) {
      throw new Error('Invalid shard format. Expected format: "index/total" (e.g., "1/4")')
    }

    const shardIndex = parseInt(shardMatch[1], 10)
    const shardTotal = parseInt(shardMatch[2], 10)

    if (shardTotal < 1) {
      throw new Error('Shard total must be at least 1')
    }

    if (shardIndex < 1 || shardIndex > shardTotal) {
      throw new Error(`Shard index ${shardIndex} must be between 1 and ${shardTotal}`)
    }

    if (testFiles.length === 0) {
      return testFiles
    }

    // Calculate which tests belong to this shard
    const shardSize = Math.ceil(testFiles.length / shardTotal)
    const startIndex = (shardIndex - 1) * shardSize
    const endIndex = Math.min(startIndex + shardSize, testFiles.length)

    return testFiles.slice(startIndex, endIndex)
  }

  /**
   * Run a specific test or all loaded tests.
   *
   * @param {string} [test]
   * @returns {Promise<void>}
   */
  async run(test) {
    await container.started()

    return new Promise((resolve, reject) => {
      const mocha = container.mocha()
      mocha.files = this.testFiles
      if (test) {
        if (!fsPath.isAbsolute(test)) {
          test = fsPath.join(global.codecept_dir, test)
        }
        mocha.files = mocha.files.filter(t => fsPath.basename(t, '.js') === test || t === test)
      }
      const done = () => {
        event.emit(event.all.result, container.result())
        event.emit(event.all.after, this)
        resolve()
      }

      try {
        event.emit(event.all.before, this)
        mocha.run(() => done())
      } catch (e) {
        output.error(e.stack)
        reject(e)
      }
    })
  }

  /**
   * Returns the version string of CodeceptJS.
   *
   * @returns {string} The version string.
   */
  static version() {
    return JSON.parse(readFileSync(`${__dirname}/../package.json`, 'utf8')).version
  }
}

module.exports = Codecept
